Skip to content

feat: in-browser OPFS LibreDB playground at /playground (#19)#20

Merged
cevheri merged 22 commits into
mainfrom
feat/playground-opfs-editor
Jun 30, 2026
Merged

feat: in-browser OPFS LibreDB playground at /playground (#19)#20
cevheri merged 22 commits into
mainfrom
feat/playground-opfs-editor

Conversation

@cevheri

@cevheri cevheri commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a public /playground route that runs a real LibreDB database entirely in the browser — no backend, no login. The engine (@libredb/libredb@0.1.3, browser entry) runs in a Web Worker backed by OPFS, preloaded with sample data across all three lenses, with a clickable command cheatsheet beside the editor.

Closes #19.

Listed in the sidebar Explorer under the database group; not on the homepage (the marketing hero editor is unchanged).

What it does

  • Real, durable DB in the browser. OPFS-backed playground.libredb in a Worker; writes persist across reloads; per-visitor isolated; zero backend.
  • Faithful command grammar. Mirrors Studio's LibreDBProvider exactly — the five kv verbs get / put / delete / prefix / range (quote-aware tokenizer, #-comment skipping, isReservedKey filtering on scans, JSON value pretty-printing). No invented SQL/document dialect — tables/collections are conventions over the keyspace (users:<pk>, articles:<id>) recorded in the catalog.
  • CLI-parity admin commands (libredb-database/docs/CLI.md): inspect (catalog namespaces + schema), stats (file size + per-kind counts), import <json> (bulk-set in one atomic db.transact()), shown in a Manage group with use-case lines.
  • Three-lens seed: users table, articles collection, config:* kv — written through doc()/table() so the catalog is real.
  • Graceful fallback: OPFS unavailable / 2nd-tab single-writer lock → in-memory + a banner. db.close() on pagehide frees the lock for clean reacquire.
  • SSR-safe: client-only island; navigator/Worker touched only after load.

UX details

  • Clicking a cheatsheet command loads it into the editor and clears the previous result (with a "Press Run" hint) — read/edit before running.
  • A result status/echo strip (▸ <command> · N rows · X ms) + a subtle 180ms fade-in (re-triggered each run, prefers-reduced-motion aware) make an instant query legible without faking a spinner delay.

Architecture

/playground (Astro, SSR-safe) → StudioShell → Playground island (client <script>)
  editor + result grid + status echo + cheatsheet
     │ postMessage({op,…})   ▲ {kind:'result',…}
     ▼                       │
  db.worker.ts (Web Worker)  — owns the OPFS sync-access handle
     open({path, fs: opfsFileSystem(handle)}) | open()  → seed if empty → execute

Pure, unit-tested modules hold the logic: protocol.ts (grammar/parse) and engine.ts (lens dispatch) — both run under bun:test against the in-memory open() (the browser entry imports no node:).

Testing

  • 57 bun:test unit tests (parser + engine against a real in-memory db).
  • bun run gate green: typecheck (0 errors), prettier, oxlint, knip, tests.
  • Browser-verified (Chromium/localhost): seed renders; badge OPFS · persistent; put/update/delete work and persist across reload; range a z scans all namespaces in byte order with no reserved-key leak; inspect/stats/import correct; no node:fs in the client bundle; 0 console errors.

Notes

  • Per the repo's deploy flow, this won't go live until a tag + GitHub release — merging the PR only stages it.
  • Specs/plan in docs/superpowers/.

🤖 Generated with Claude Code

cevheri and others added 15 commits June 30, 2026 04:21
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…19)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed (#19)

Verified in Chromium on localhost:
- seed renders 3 users on first load; badge reads OPFS · persistent
- cheatsheet insert -> 4th row; survives reload (OPFS durable)
- reset returns to seed; kv prefix + doc.find scans render
- 0 console errors; no node:fs in client bundle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- add 'playground' section (schema: database) so the Explorer tree shows it
  as a page under the pre-alpha database group, where users expect it
- exclude 'playground' from [section].astro getStaticPaths (served by its
  own interactive page, avoids duplicate-route collision)
- mark the page active='playground' so the sidebar row highlights
- update sections test for the new database-group page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… converter (#19)

LibreDB is an ordered key-value store; tables and document collections are
conventions over the keyspace (users:<pk>, articles:<id>) recorded in the
catalog — not separate command dialects. The previous design invented a
SQL-ish (select/insert) + doc.* translator that does not exist in LibreDB or
Studio's LibreDBProvider, misrepresenting the engine and adding a maintenance
liability.

Now mirrors docs/providers/libredb.md exactly:
- one grammar = the five kv verbs: get/put/delete/prefix/range
- quote-aware tokenizer; case-insensitive verbs; #-comment/blank-line skipping
- prefix/range hide the reserved catalog namespace via isReservedKey
- JSON values pretty-printed in the grid (renderValue)
- seed still writes through doc()/table() so the catalog is real; the visitor
  operates every namespace through the same kv verbs
- cheatsheet regrouped by namespace (users:* relational, articles:* document,
  config:* kv) with a teaching note; reset is a sandbox action, not a verb

Browser-verified: prefix users: shows rows as keys+JSON; put users:4 persists;
range a z scans all namespaces in byte order with no reserved-key leak; 0
console errors. Gate: 49 tests, 0 type errors, lint/format/knip clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…of auto-running (#19)

Clicking a cheatsheet command now fills the editor, clears the previous
result, shows a 'Press Run' hint, and focuses the input — so visitors can
read/edit the command before executing it. Run / Ctrl+Enter still execute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orted) (#19)

Mirrors libredb-database/docs/CLI.md's database-level commands, all fully
supported by the browser build:
- inspect: lists catalogued namespaces + kind + relational schema via catalog(db)
- stats: file size (OPFS handle.getSize(), worker-supplied) + per-kind counts
- import <json>: bulk-set a JSON object of string values in ONE db.transact();
  refuses reserved keys via isReservedKey

Shown as a separate 'Manage' (admin) group in the cheatsheet, each with a
use-case line; clicking loads into the editor (then Run), consistent with the
other commands. CLI file concepts (path, .lock, --force) have no browser analog
and are intentionally omitted; scan/set are not aliased (prefix/put stay canonical).

Browser-verified: inspect shows users=relational(+schema)/articles=document;
stats shows 722 bytes, 2 namespaces, kv 0/doc 1/rel 1; import persists across
reload (atomic + durable); 0 console errors. Gate: 57 tests, 0 type errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

The import sample used user:9 (singular) right above the users:* table
(plural), reading as if it wrote to that table — but import does raw kv writes.
Switched the example to session:a1/a2 (a clearly new namespace) so it no longer
collides visually with the seeded users table. Aligned the protocol test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gible (#19)

Queries finish in <1ms, so the grid swapped with no perceptible feedback —
re-running looked like nothing happened (change blindness). Instead of faking a
spinner delay (which would hide the engine's speed), add two honest cues:
- a status/echo strip above the grid: '▸ <command> · N rows · X ms', updated
  every run and acting as the screen-reader announcer (grid aria-live removed to
  avoid double-announce)
- a subtle 180ms fade+slide-in (pg-result-in), re-triggered on every render so
  even an identical re-run is visibly new; disabled under prefers-reduced-motion

Browser-verified: status shows '▸ prefix users: · 3 rows · 34ms' then updates to
'▸ prefix config: · 3 rows · 13ms' on the next run; 0 console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…column) (#19)

The put users:4 {"id":"4",...} example shows the id twice, which reads as
redundant. It is faithful — table.insert stores a row at key <table>:<pk> and
keeps the pk as a column, so seeded rows look the same. Added a one-line note
under the users:* group explaining the convention rather than hiding it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new public /playground route to the Astro site that runs a real LibreDB database fully in-browser (OPFS-backed when available) via a Web Worker, with a small UI (editor + results + cheatsheet), seeded sample data, and Bun unit tests for the parser/engine.

Changes:

  • Introduces a worker-based OPFS/in-memory LibreDB runtime plus a main-thread client bridge for running commands and rendering results.
  • Adds /playground page + StudioShell UI components (Playground + Cheatsheet) and navigation/sections metadata updates.
  • Adds Bun unit tests for the command protocol/parser and execution engine; adds @libredb/libredb@0.1.3 dependency.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/styles/global.css Adds result fade/slide animation class used by the playground UI (reduced-motion aware).
src/scripts/playground/protocol.ts Defines command grammar + worker message protocol and a parser for supported commands.
src/scripts/playground/protocol.test.ts Adds Bun unit tests covering parsing behavior and error cases.
src/scripts/playground/engine.ts Implements seed data and command execution against LibreDB (kv + catalog-based admin commands).
src/scripts/playground/engine.test.ts Adds Bun unit tests for seed + execute behavior against in-memory LibreDB.
src/scripts/playground/db.worker.ts Web Worker that acquires OPFS handle (or falls back to memory), seeds, executes commands, and supports reset/close.
src/scripts/playground/client.ts Main-thread worker bridge + DOM rendering (status strip, grid, cheatsheet interactions, lifecycle teardown).
src/pages/playground.astro Adds the /playground route wiring Layout + StudioShell + Playground component.
src/pages/[section].astro Excludes playground from dynamic section routes to avoid clashing with the dedicated page.
src/data/sections.ts Adds playground section metadata for sidebar/explorer grouping under the database schema.
src/data/sections.test.ts Updates tests to include the new playground database-schema section.
src/components/studio/TopBar.astro Adds a top-bar navigation link to /playground.
src/components/studio/Playground.astro New playground UI shell (editor, run/reset, status strip, results, console, banner/badge).
src/components/studio/Cheatsheet.astro New clickable cheatsheet sidebar including admin/manage commands.
package.json Adds @libredb/libredb@0.1.3 dependency.
docs/superpowers/specs/2026-06-30-playground-opfs-editor-design.md Adds design/spec documentation for the playground feature.
docs/superpowers/plans/2026-06-30-playground-opfs-editor.md Adds a detailed implementation plan and verification checklist.
bun.lock Locks the added @libredb/libredb@0.1.3 dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +124 to +127
case 'put': {
const r = kv(db).set(cmd.key, cmd.value);
return { kind: 'message', message: `OK · changed ${r.changed}` };
}
Comment on lines +128 to +131
case 'delete': {
const r = kv(db).delete(cmd.key);
return { kind: 'message', message: `OK · changed ${r.changed}` };
}
Comment thread src/scripts/playground/client.ts Outdated
Comment on lines +123 to +127
// Free the exclusive OPFS lock so a reload reacquires cleanly.
window.addEventListener('pagehide', () => {
void call({ op: 'close' });
worker.terminate();
});
cevheri and others added 2 commits June 30, 2026 06:06
Previously only write commands (put/delete/import) and errors hit the console;
reads (get/prefix/range/inspect/stats) rendered to the grid and were never
logged — inconsistent and sparse. Now EVERY run appends one structured entry,
newest-first:

  HH:MM:SS  ✓/✗  <command>  <summary>  <ms>  <source>

- time (HH:MM:SS; full locale date+time on hover via title)
- status (✓ success / ✗ error)
- command, outcome summary (N rows / changed N / error text)
- duration (ms), and trigger source (editor / system)
- capped at 50 entries; cheatsheet-load no longer wipes the log (it's history)

The status strip stays as the grid's current-result header and remains the sole
aria-live announcer (log aria-live dropped to avoid double announcements).

Browser-verified: read/write/error all log correctly with source + timing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… worker teardown race (#19)

- put/delete now refuse reserved-namespace keys, matching import and Studio's
  CLI safety rule (the raw kv lens is unguarded, so a U+0000-prefixed key could
  otherwise overwrite/remove catalog entries and corrupt the sandbox)
- worker teardown: on 'close' the worker now db.close()s and self.close()s; the
  client no longer calls worker.terminate() on pagehide (it raced and usually
  won before the worker ran db.close(), so the OPFS lock wasn't released cleanly)
- pagehide skips bfcache (event.persisted) so a back/forward restore keeps a
  live worker
- tests: put/delete reserved-key refusal (58 total)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevheri

cevheri commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — all three addressed in 52e9a6e:

  1. put writes to reserved keys — fixed. put now refuses keys where isReservedKey(cmd.key), matching import and Studio's CLI safety rule. (The raw kv lens is intentionally unguarded in the engine, so without this a \u0000-prefixed key could overwrite catalog entries.)
  2. delete on reserved keys — fixed the same way; delete refuses reserved keys too.
  3. pagehide terminate() races close — fixed. The client no longer calls worker.terminate(); instead the worker, on close, runs db.close() (flush + release the OPFS handle) and then self.close(). The pagehide handler also skips bfcache (event.persisted) so a back/forward restore keeps a live worker.

Added unit tests for the put/delete reserved-key refusal (58 tests total, gate green).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.

Comment on lines +19 to +21
export function isSeeded(db: Database): boolean {
return kv(db).get('config:theme') !== undefined;
}
Comment on lines +77 to +80
| `src/scripts/playground/seed.ts` | Sample dataset definition + a `seed(db)` function (imported by the worker). |
| `src/scripts/playground/db.worker.ts` | The Worker. Owns the OPFS handle, opens db (durable→memory fallback), seeds on first open, dispatches parsed commands to lenses, returns results, `db.close()` on teardown. |
| `src/scripts/playground/client.ts` | Main-thread bridge: spawns worker, `call()` request/response with ids, wires Run button + cheatsheet clicks + Reset, renders result grid, shows fallback banner, posts `close` + `terminate()` on `pagehide`. |
| `src/scripts/playground/protocol.test.ts` | `bun:test` unit tests for the parser/grammar. |
5. Worker parses, executes against the right lens, posts `{id, result}` or `{id, error}`.
6. Client renders rows in the grid; errors → red console toast.
7. Reset → `call(op:"reset")` → worker truncates the relevant keys/tables and reseeds.
8. `pagehide` → `call(op:"close")` then `worker.terminate()`.
Comment on lines +752 to +756
// Free the exclusive OPFS lock so a reload reacquires cleanly.
window.addEventListener("pagehide", () => {
void call({ op: "close" });
worker.terminate();
});
… docs (#19)

Round 2 of Copilot review:
- isSeeded() keyed off config:theme, which a visitor can delete — then a reload
  saw the db as unseeded and re-ran seed(), clobbering edited users:*/articles:*
  rows. Now isSeeded() = catalog(db).size > 0: the catalog is reserved (and
  write-protected), survives row deletions, and can't be emptied by user
  commands, so a deleted kv key never triggers a re-seed. (+regression test)
- spec Files table: replaced the non-existent seed.ts row with engine.ts (seed
  lives there) and added engine.test.ts
- spec + plan teardown: corrected to the shipped semantics — worker self-closes
  on 'close'; client does not worker.terminate() on pagehide

Browser-verified: fresh OPFS origin seeds correctly. Gate: 59 tests, 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevheri

cevheri commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 — all four addressed in 1d0ff40:

  1. isSeeded() keyed off a deletable key — fixed (good catch, real bug). It now uses catalog(db).size > 0 instead of config:theme. The catalog is reserved and write-protected (from the previous round), survives row deletions, and can't be emptied by user commands — so deleting a kv key no longer makes a reload re-run seed() and clobber edits. Added a regression test.
  2. Spec lists seed.ts — fixed. The shipped code merged the seed into engine.ts; the Files table now reflects engine.ts (+ engine.test.ts).
  3. Spec teardown says worker.terminate() — fixed to the shipped semantics (worker self-closes on close; client does not terminate; pagehide skips bfcache).
  4. Plan teardown snippet says worker.terminate() — same correction applied.

Gate: 59 tests, 0 type errors; fresh-origin browser check confirms seeding still works.

cevheri and others added 2 commits June 30, 2026 10:41
From the external STRIDE/UX report (non-blocking items):
- parseImport: Object.create(null) accumulator so a literal "__proto__" key
  imports as an own property instead of silently no-op'ing on the prototype
  setter (+test). Not a pollution vuln — values are string-constrained.
- Run: disable + "… Running" while a query is in flight; a busy guard ignores
  re-entrant runs (fixes double-click / repeated Ctrl+Enter sending 2 messages).
- Reset: inline two-step confirm ("Click again to confirm", 3s disarm) so a
  destructive wipe needs intent — no blocking dialog.
- stats: exhaustive switch over catalog kinds with a never-guard (a future kind
  becomes a compile error instead of being miscounted as kv).
- activity log height max-h-32 -> max-h-48; Run gets disabled: styling.
- TopBar /playground link reflects active state via studio.ts syncActive
  (the bar is transition:persist'd, so SSR-time active would go stale under VT).

Note: the report's mobile order- suggestion was not needed — the editor section
already precedes the cheatsheet in the DOM, so it renders first on mobile.

Gate: 60 tests, 0 type errors; browser-verified busy state, two-step reset,
active topbar link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per request — protect destructive writes without confirm-dialog friction:
- L0 reserved keys: hard-refused (unchanged)
- L1 create (new put) / no-op (delete of a missing key): silent success (✓)
- L2 destructive (overwrite an existing value / delete an existing key): runs
  immediately but is flagged 'warn' (⚠, warn colour in the status strip + log)
  and echoes the PRIOR value, so an accidental clobber is visible and
  recoverable (re-put it to undo)

RunResult.message gains an optional level: 'ok' | 'warn'; the activity log and
status strip render ✓/⚠/✗ with ok/warn/bad colours. engine echoes a compact
single-line preview() of the prior value.

Browser-verified: created/overwrote(was:dark)/deleted(was:en)/not-found all
render with the right icon + level; 0 console errors. Gate: 60 tests, 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 4 comments.

Comment on lines +89 to +93
async function doReset(): Promise<void> {
const res = await call({ op: 'reset' });
if (res.kind === 'result') render(res.result, 'reset', 0, 'system');
await run('prefix users:', 'system');
}
Comment thread src/components/studio/Playground.astro Outdated
Comment on lines +67 to +68
<!-- Result grid -->
<div data-pg-grid class="min-h-0 flex-1 overflow-auto" aria-label="Query results"></div>
Comment on lines +71 to +75
<div
data-pg-log
class="max-h-48 shrink-0 space-y-1 overflow-auto border-t border-edge bg-canvas px-4 py-2 font-mono text-[12px]"
aria-label="Activity log"
>
Comment thread src/scripts/playground/db.worker.ts Outdated
Comment on lines +63 to +66
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
await ready;
const msg = event.data;
try {
…les (#19)

Round 3 of Copilot review:
- doReset() now shares the single in-flight guard with run(): both Run and Reset
  are disabled for the duration, and the post-reset refresh runs via an unguarded
  exec() path (previously it early-returned when a query was still busy, leaving
  the grid stale). Reset can no longer overlap a query.
- db.worker.ts: serialize all message handling on one promise chain (starting
  with boot) so only one op touches db at a time — a run can't land mid-reset and
  close can't race an in-flight op. execute is sync so the prior worst case was a
  caught error, but this makes the worker robustly serial regardless of caller.
- a11y: give the results grid and activity log role="region" so their aria-label
  names are reliably exposed (a bare div+aria-label isn't). Deliberately NOT
  role="log" — that implies a live region and would re-introduce double
  announcements with the status strip.

Browser-verified: reset disables both buttons then refreshes to seed; grid/log
expose region+name; 0 console errors. Gate: 60 tests, 0 type errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevheri

cevheri commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Round 3 — all four addressed in cbc3aff:

  1. doReset() not under the busy guard — fixed (real bug). run and reset now share one in-flight guard: both buttons disable for the duration, and the post-reset refresh runs via an unguarded exec() so it no longer early-returns (which had left the grid stale). Reset can't overlap a query.
  2. aria-label on a bare <div> (results grid) — added role="region" so "Query results" is exposed.
  3. Same for the activity log — added role="region" ("Activity log"). Deliberately not role="log" — it implies a live region and would re-introduce the double-announce with the status strip.
  4. async self.onmessage not serialized — the worker now chains all message handling on one promise (starting with boot), so only one op touches db at a time; a run can't land mid-reset and close can't race an in-flight op. (execute is sync, so the prior worst case was a caught error, but this makes it robustly serial regardless of caller.)

Browser-verified: reset disables both buttons then refreshes to seed; grid/log expose region+name; 0 console errors. Gate: 60 tests, 0 type errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevheri cevheri merged commit e79dec7 into main Jun 30, 2026
3 checks passed
@cevheri cevheri deleted the feat/playground-opfs-editor branch June 30, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: in-browser, zero-backend LibreDB editor demo (OPFS-backed) at libredb.org/database-opfs

2 participants